# -*- coding: utf-8 -*-
# Calibre-Web Automated – fork of Calibre-Web
# Copyright (C) 2018-2025 Calibre-Web contributors
# Copyright (C) 2024-2025 Calibre-Web Automated contributors
# SPDX-License-Identifier: GPL-3.0-or-later
# See CONTRIBUTORS for full list of authors.

import os
import re
import json
import operator
import time
import sys
import string
import requests
from datetime import datetime, timedelta
from datetime import time as datetime_time
from functools import wraps
from urllib.parse import urlparse

from flask import Blueprint, flash, redirect, url_for, abort, request, make_response, send_from_directory, g, Response, jsonify
from markupsafe import Markup
from .cw_login import current_user
from flask_babel import gettext as _
from flask_babel import get_locale, format_time, format_datetime, format_timedelta
from sqlalchemy import and_
from sqlalchemy.orm.attributes import flag_modified
from sqlalchemy.exc import IntegrityError, OperationalError, InvalidRequestError
from sqlalchemy.sql.expression import func, or_, text

from . import constants, logger, helper, services, cli_param
from . import db, calibre_db, ub, web_server, config, updater_thread, gdriveutils, \
    kobo_sync_status, schedule
from .helper import check_valid_domain, send_test_mail, reset_password, generate_password_hash, check_email, \
    valid_email, check_username
from .embed_helper import get_calibre_binarypath
from .gdriveutils import is_gdrive_ready, gdrive_support
from .render_template import render_title_template, get_sidebar_config
from .services.worker import WorkerThread
from .usermanagement import user_login_required
from .cw_babel import get_available_translations, get_available_locale, get_user_locale_language
from . import debug_info
from .string_helper import strip_whitespaces

log = logger.create()

feature_support = {
    'ldap': bool(services.ldap),
    'goodreads': bool(services.goodreads_support),
    'kobo': bool(services.kobo),
    'hardcover' : bool(services.hardcover),
    'updater': constants.UPDATER_AVAILABLE,
    'gmail': bool(services.gmail),
    'scheduler': schedule.use_APScheduler,
    'gdrive': gdrive_support
}

try:
    import rarfile  # pylint: disable=unused-import

    feature_support['rar'] = True
except (ImportError, SyntaxError):
    feature_support['rar'] = False

try:
    from .oauth_bb import oauth_check, oauthblueprints

    feature_support['oauth'] = True
except ImportError as err:
    log.debug('Cannot import Flask-Dance, login with Oauth will not work: %s', err)
    feature_support['oauth'] = False
    oauthblueprints = []
    oauth_check = {}

admi = Blueprint('admin', __name__)


def admin_required(f):
    """
    Checks if current_user.role == 1
    """

    @wraps(f)
    def inner(*args, **kwargs):
        if current_user.role_admin():
            return f(*args, **kwargs)
        abort(403)

    return inner


@admi.before_app_request
def before_request():
    # Safety net: if not configured but metadata.db now exists at default location, auto-set without redirect loop
    if not config.db_configured:
        try:
            default_metadata = '/calibre-library/metadata.db'
            if (not config.config_calibre_dir or not os.path.isfile(os.path.join(config.config_calibre_dir, 'metadata.db'))) \
                    and os.path.isfile(default_metadata):
                config.config_calibre_dir = os.path.dirname(default_metadata)
                log.info('[autoconfig] Late-detected calibre library at %s; updating config and rebuilding db session', config.config_calibre_dir)
                try:
                    config.save()
                except Exception as e:
                    log.error('Failed to save late autoconfig: %s', e)
                # Re-run calibre db setup so subsequent handlers see a configured DB
                from . import db as _db, cli_param as _cli_param
                _db.CalibreDB.update_config(config)
                _db.CalibreDB.setup_db(config.config_calibre_dir, _cli_param.settings_path)
        except Exception as e:
            log.error('Autoconfig safety net error: %s', e)
    #try:
        #if not ub.check_user_session(current_user.id,
        #                             flask_session.get('_id')) and 'opds' not in request.path \
        #  and config.config_session == 1:
        #    logout_user()
    #except AttributeError:
    #    pass    # ? fails on requesting /ajax/emailstat during restart ?
    g.constants = constants
    g.google_site_verification = os.getenv('GOOGLE_SITE_VERIFICATION', '')
    g.allow_registration = config.config_public_reg
    g.allow_anonymous = config.config_anonbrowse
    g.allow_upload = config.config_uploading
    # Use per-user theme if available; fallback to global config.config_theme for legacy/anonymous
    try:
        g.current_theme = getattr(current_user, 'theme', config.config_theme)
        if current_user.is_anonymous and not hasattr(current_user, 'theme'):
            g.current_theme = config.config_theme
    except Exception:
        g.current_theme = getattr(config, 'config_theme', 1)
    g.config_authors_max = config.config_authors_max
    if '/static/' not in request.path and not config.db_configured and \
        request.endpoint not in ('admin.ajax_db_config',
                                 'admin.simulatedbchange',
                                 'admin.db_configuration',
                                 'web.login',
                                 'web.login_post',
                                 'web.logout',
                                 'admin.load_dialogtexts',
                                 'admin.ajax_pathchooser'):
        return redirect(url_for('admin.db_configuration'))


#@admi.route("/admin")
#@user_login_required
#def admin_forbidden():
#    abort(403)


@admi.route("/shutdown", methods=["POST"])
@user_login_required
@admin_required
def shutdown():
    task = request.get_json().get('parameter', -1)
    show_text = {}
    if task in (0, 1):  # valid commandos received
        # close all database connections
        calibre_db.dispose()
        ub.dispose()

        if task == 0:
            show_text['text'] = _('Server restarted, please reload page.')
        else:
            show_text['text'] = _('Performing Server shutdown, please close window.')
        # stop gevent/tornado server
        web_server.stop(task == 0)
        return json.dumps(show_text)

    if task == 2:
        log.warning("reconnecting to calibre database")
        calibre_db.reconnect_db(config, ub.app_DB_path)
        show_text['text'] = _('Success! Database Reconnected')
        return json.dumps(show_text)

    show_text['text'] = _('Unknown command')
    return json.dumps(show_text), 400


@admi.route("/metadata_backup", methods=["POST"])
@user_login_required
@admin_required
def queue_metadata_backup():
    show_text = {}
    log.warning("Queuing all books for metadata backup")
    helper.set_all_metadata_dirty()
    show_text['text'] = _('Success! Books queued for Metadata Backup, please check Tasks for result')
    return json.dumps(show_text)


# method is available without login and not protected by CSRF to make it easy reachable, is per default switched off
# needed for docker applications, as changes on metadata.db from host are not visible to application
@admi.route("/reconnect", methods=['GET'])
def reconnect():
    if cli_param.reconnect_enable:
        calibre_db.reconnect_db(config, ub.app_DB_path)
        return json.dumps({})
    else:
        log.debug("'/reconnect' was accessed but is not enabled")
        abort(404)


@admi.route("/ajax/updateThumbnails", methods=['POST'])
@user_login_required
@admin_required
def update_thumbnails():
    # Always allow manual thumbnail cache updates
    log.info("Update of Cover cache requested")

    try:
        from .tasks.thumbnail import TaskGenerateCoverThumbnails
        task_id = helper.update_thumbnail_cache()

        # Check if there are any books to process
        books_with_covers = TaskGenerateCoverThumbnails.get_books_with_covers()
        book_count = len(books_with_covers)

        if book_count > 0:
            message = _('Thumbnail cache refresh started for {} book(s). This may take a few minutes.').format(book_count)
        else:
            message = _('No books with covers found to process.')

        return jsonify({
            'success': True,
            'message': message,
            'book_count': book_count,
            'task_id': str(task_id) if task_id else None
        })
    except Exception as e:
        log.error(f"Error starting thumbnail refresh: {e}")
        return jsonify({
            'success': False,
            'message': _('Failed to start thumbnail refresh: {}').format(str(e))
        })


def cwa_get_package_versions() -> tuple[str, str, str, str]:
    try:
        with open("/app/CWA_RELEASE", "r") as f:
            cwa_version = f.read()
    except Exception:
        cwa_version = "Unknown"

    try:
        with open("/app/KEPUBIFY_RELEASE", "r") as f:
            kepubify_version = f.read()
    except Exception:
        kepubify_version = "Unknown"

    try:
        with open("/CALIBRE_RELEASE", "r") as f:
            calibre_version = f.read()
    except Exception:
        calibre_version = "Unknown"

    return cwa_version, kepubify_version, calibre_version


@admi.route("/admin/view")
@user_login_required
@admin_required
def admin():
    version = updater_thread.get_current_version_info()
    cwa_version, kepubify_version, calibre_version = cwa_get_package_versions()
    if version is False:
        commit = _('Unknown')
    else:
        if 'datetime' in version:
            commit = version['datetime']

            tz = timedelta(seconds=time.timezone if (time.localtime().tm_isdst == 0) else time.altzone)
            form_date = datetime.strptime(commit[:19], "%Y-%m-%dT%H:%M:%S")
            if len(commit) > 19:  # check if string has timezone
                if commit[19] == '+':
                    form_date -= timedelta(hours=int(commit[20:22]), minutes=int(commit[23:]))
                elif commit[19] == '-':
                    form_date += timedelta(hours=int(commit[20:22]), minutes=int(commit[23:]))
            commit = format_datetime(form_date - tz, format='short')
        else:
            commit = version['version'].replace("b", " Beta")

    all_user = ub.session.query(ub.User).all()
    # email_settings = mail_config.get_mail_settings()
    schedule_time = format_time(datetime_time(hour=config.schedule_start_time), format="short")
    t = timedelta(hours=config.schedule_duration // 60, minutes=config.schedule_duration % 60)
    schedule_duration = format_timedelta(t, threshold=.99)

    return render_title_template("admin.html", allUser=all_user, config=config, commit=commit,
                                 cwa_version=cwa_version, kepubify_version=kepubify_version,
                                 calibre_version=calibre_version, feature_support=feature_support,
                                 schedule_time=schedule_time, schedule_duration=schedule_duration,
                                 title=_("Admin page"), page="admin")


@admi.route("/admin/dbconfig", methods=["GET", "POST"])
@user_login_required
@admin_required
def db_configuration():
    if request.method == "POST":
        return _db_configuration_update_helper()
    return _db_configuration_result()


@admi.route("/admin/config", methods=["GET"])
@user_login_required
@admin_required
def configuration():
    return render_title_template("config_edit.html",
                                 config=config,
                                 provider=oauthblueprints,
                                 feature_support=feature_support,
                                 title=_("Basic Configuration"), page="config")


@admi.route("/admin/ajaxconfig", methods=["POST"])
@user_login_required
@admin_required
def ajax_config():
    return _configuration_update_helper()


@admi.route("/admin/ajaxdbconfig", methods=["POST"])
@user_login_required
@admin_required
def ajax_db_config():
    return _db_configuration_update_helper()


@admi.route("/admin/alive", methods=["GET"])
@user_login_required
@admin_required
def calibreweb_alive():
    return "", 200


@admi.route("/admin/viewconfig")
@user_login_required
@admin_required
def view_configuration():
    read_column = calibre_db.session.query(db.CustomColumns) \
        .filter(and_(db.CustomColumns.datatype == 'bool', db.CustomColumns.mark_for_delete == 0)).all()
    restrict_columns = calibre_db.session.query(db.CustomColumns) \
        .filter(and_(db.CustomColumns.datatype == 'text', db.CustomColumns.mark_for_delete == 0)).all()
    languages = calibre_db.speaking_language()
    translations = get_available_locale()
    return render_title_template("config_view_edit.html", conf=config, readColumns=read_column,
                                 restrictColumns=restrict_columns,
                                 languages=languages,
                                 translations=translations,
                                 title=_("UI Configuration"), page="uiconfig")


@admi.route("/admin/usertable")
@user_login_required
@admin_required
def edit_user_table():
    visibility = current_user.view_settings.get('useredit', {})
    languages = calibre_db.speaking_language()
    translations = get_available_locale()
    all_user = ub.session.query(ub.User)
    tags = calibre_db.session.query(db.Tags) \
        .join(db.books_tags_link) \
        .join(db.Books) \
        .filter(calibre_db.common_filters()) \
        .group_by(text('books_tags_link.tag')) \
        .order_by(db.Tags.name).all()
    if config.config_restricted_column:
        try:
            custom_values = calibre_db.session.query(db.cc_classes[config.config_restricted_column]).all()
        except (KeyError, AttributeError, IndexError):
            custom_values = []
            log.error("Custom Column No.{} does not exist in calibre database".format(
                config.config_restricted_column))
            flash(_("Custom Column No.%(column)d does not exist in calibre database",
                    column=config.config_restricted_column),
                  category="error")
    else:
        custom_values = []
    if not config.config_anonbrowse:
        all_user = all_user.filter(ub.User.role.op('&')(constants.ROLE_ANONYMOUS) != constants.ROLE_ANONYMOUS)
    kobo_support = feature_support['kobo'] and config.config_kobo_sync
    return render_title_template("user_table.html",
                                 users=all_user.all(),
                                 tags=tags,
                                 custom_values=custom_values,
                                 translations=translations,
                                 languages=languages,
                                 visiblility=visibility,
                                 all_roles=constants.ALL_ROLES,
                                 kobo_support=kobo_support,
                                 sidebar_settings=constants.sidebar_settings,
                                 title=_("Edit Users"),
                                 page="usertable")


@admi.route("/ajax/listusers")
@user_login_required
@admin_required
def list_users():
    off = int(request.args.get("offset") or 0)
    limit = int(request.args.get("limit") or 10)
    search = request.args.get("search")
    sort = request.args.get("sort", "id")
    state = None
    if sort == "state":
        state = json.loads(request.args.get("state", "[]"))
    else:
        if sort not in ub.User.__table__.columns.keys():
            sort = "id"
    order = request.args.get("order", "").lower()

    if sort != "state" and order:
        order = text(sort + " " + order)
    elif not state:
        order = ub.User.id.asc()

    all_user = ub.session.query(ub.User)
    if not config.config_anonbrowse:
        all_user = all_user.filter(ub.User.role.op('&')(constants.ROLE_ANONYMOUS) != constants.ROLE_ANONYMOUS)

    total_count = filtered_count = all_user.count()

    if search:
        all_user = all_user.filter(or_(func.lower(ub.User.name).ilike("%" + search + "%"),
                                       func.lower(ub.User.kindle_mail).ilike("%" + search + "%"),
                                       func.lower(ub.User.email).ilike("%" + search + "%")))
    if state:
        users = calibre_db.get_checkbox_sorted(all_user.all(), state, off, limit, request.args.get("order", "").lower())
    else:
        users = all_user.order_by(order).offset(off).limit(limit).all()
    if search:
        filtered_count = len(users)

    for user in users:
        if user.default_language == "all":
            user.default = _("All")
        else:
            user.default = get_user_locale_language(user.default_language)

    table_entries = {'totalNotFiltered': total_count, 'total': filtered_count, "rows": users}
    js_list = json.dumps(table_entries, cls=db.AlchemyEncoder)
    response = make_response(js_list)
    response.headers["Content-Type"] = "application/json; charset=utf-8"
    return response


@admi.route("/ajax/deleteuser", methods=['POST'])
@user_login_required
@admin_required
def delete_user():
    user_ids = request.form.to_dict(flat=False)
    users = None
    message = ""
    if "userid[]" in user_ids:
        users = ub.session.query(ub.User).filter(ub.User.id.in_(user_ids['userid[]'])).all()
    elif "userid" in user_ids:
        users = ub.session.query(ub.User).filter(ub.User.id == user_ids['userid'][0]).all()
    count = 0
    errors = list()
    success = list()
    if not users:
        log.error("User not found")
        return Response(json.dumps({'type': "danger", 'message': _("User not found")}), mimetype='application/json')
    for user in users:
        try:
            message = _delete_user(user)
            count += 1
        except Exception as ex:
            log.error(ex)
            errors.append({'type': "danger", 'message': str(ex)})

    if count == 1:
        log.info("User {} deleted".format(user_ids))
        success = [{'type': "success", 'message': message}]
    elif count > 1:
        log.info("Users {} deleted".format(user_ids))
        success = [{'type': "success", 'message': _("{} users deleted successfully").format(count)}]
    success.extend(errors)
    return Response(json.dumps(success), mimetype='application/json')


@admi.route("/ajax/getlocale")
@user_login_required
@admin_required
def table_get_locale():
    locale = get_available_locale()
    ret = list()
    current_locale = get_locale()
    for loc in locale:
        ret.append({'value': str(loc), 'text': loc.get_language_name(current_locale)})
    return json.dumps(ret)


@admi.route("/ajax/getdefaultlanguage")
@user_login_required
@admin_required
def table_get_default_lang():
    languages = calibre_db.speaking_language()
    ret = list()
    ret.append({'value': 'all', 'text': _('Show All')})
    for lang in languages:
        ret.append({'value': lang.lang_code, 'text': lang.name})
    return json.dumps(ret)


@admi.route("/ajax/editlistusers/<param>", methods=['POST'])
@user_login_required
@admin_required
def edit_list_user(param):
    vals = request.form.to_dict(flat=False)
    all_user = ub.session.query(ub.User)
    if not config.config_anonbrowse:
        all_user = all_user.filter(ub.User.role.op('&')(constants.ROLE_ANONYMOUS) != constants.ROLE_ANONYMOUS)
    # only one user is posted
    if "pk" in vals:
        users = [all_user.filter(ub.User.id == vals['pk'][0]).one_or_none()]
    else:
        if "pk[]" in vals:
            users = all_user.filter(ub.User.id.in_(vals['pk[]'])).all()
        else:
            return _("Malformed request"), 400
    if 'field_index' in vals:
        vals['field_index'] = vals['field_index'][0]
    if 'value' in vals:
        vals['value'] = vals['value'][0]
    elif not ('value[]' in vals):
        return _("Malformed request"), 400
    for user in users:
        try:
            if param in ['denied_tags', 'allowed_tags', 'allowed_column_value', 'denied_column_value']:
                if 'value[]' in vals:
                    setattr(user, param, prepare_tags(user, vals['action'][0], param, vals['value[]']))
                else:
                    setattr(user, param, strip_whitespaces(vals['value']))
            else:
                vals['value'] = strip_whitespaces(vals['value'])
                if param == 'name':
                    if user.name == "Guest":
                        raise Exception(_("Guest Name can't be changed"))
                    user.name = check_username(vals['value'])
                elif param == 'email':
                    user.email = check_email(vals['value'])
                elif param == 'kobo_only_shelves_sync':
                    user.kobo_only_shelves_sync = int(vals['value'] == 'true')
                elif param == 'kindle_mail':
                    user.kindle_mail = valid_email(vals['value']) if vals['value'] else ""
                elif param == 'kindle_mail_subject':
                    user.kindle_mail_subject = vals['value']
                elif param.endswith('role'):
                    value = int(vals['field_index'])
                    if user.name == "Guest" and value in \
                      [constants.ROLE_ADMIN, constants.ROLE_PASSWD, constants.ROLE_EDIT_SHELFS]:
                        raise Exception(_("Guest can't have this role"))
                    # check for valid value, last on checks for power of 2 value
                    if value > 0 and value <= constants.ROLE_VIEWER and (value & value - 1 == 0 or value == 1):
                        if vals['value'] == 'true':
                            user.role |= value
                        elif vals['value'] == 'false':
                            if value == constants.ROLE_ADMIN:
                                if not ub.session.query(ub.User). \
                                    filter(ub.User.role.op('&')(constants.ROLE_ADMIN) == constants.ROLE_ADMIN,
                                           ub.User.id != user.id).count():
                                    return Response(
                                        json.dumps([{'type': "danger",
                                                     'message': _("No admin user remaining, can't remove admin role",
                                                                  nick=user.name)}]), mimetype='application/json')
                            user.role &= ~value
                        else:
                            raise Exception(_("Value has to be true or false"))
                    else:
                        raise Exception(_("Invalid role"))
                elif param.startswith('sidebar'):
                    value = int(vals['field_index'])
                    if user.name == "Guest" and value == constants.SIDEBAR_READ_AND_UNREAD:
                        raise Exception(_("Guest can't have this view"))
                    # check for valid value, last on checks for power of 2 value
                    if value > 0 and value <= constants.SIDEBAR_DUPLICATES and (value & value - 1 == 0 or value == 1):
                        if vals['value'] == 'true':
                            user.sidebar_view |= value
                        elif vals['value'] == 'false':
                            user.sidebar_view &= ~value
                        else:
                            raise Exception(_("Value has to be true or false"))
                    else:
                        raise Exception(_("Invalid view"))
                elif param == 'locale':
                    if user.name == "Guest":
                        raise Exception(_("Guest's Locale is determined automatically and can't be set"))
                    if vals['value'] in get_available_translations():
                        user.locale = vals['value']
                    else:
                        raise Exception(_("No Valid Locale Given"))
                elif param == 'default_language':
                    languages = calibre_db.session.query(db.Languages) \
                        .join(db.books_languages_link) \
                        .join(db.Books) \
                        .filter(calibre_db.common_filters()) \
                        .group_by(text('books_languages_link.lang_code')).all()
                    lang_codes = [lang.lang_code for lang in languages] + ["all"]
                    if vals['value'] in lang_codes:
                        user.default_language = vals['value']
                    else:
                        raise Exception(_("No Valid Book Language Given"))
                else:
                    return _("Parameter not found"), 400
        except Exception as ex:
            log.error_or_exception(ex)
            return str(ex), 400
    ub.session_commit()
    return ""


@admi.route("/ajax/user_table_settings", methods=['POST'])
@user_login_required
@admin_required
def update_table_settings():
    current_user.view_settings['useredit'] = json.loads(request.data)
    try:
        try:
            flag_modified(current_user, "view_settings")
        except AttributeError:
            pass
        ub.session.commit()
    except (InvalidRequestError, OperationalError):
        log.error("Invalid request received: {}".format(request))
        return "Invalid request", 400
    return ""


@admi.route("/admin/viewconfig", methods=["POST"])
@user_login_required
@admin_required
def update_view_configuration():
    to_save = request.form.to_dict()

    _config_string(to_save, "config_calibre_web_title")
    _config_string(to_save, "config_columns_to_ignore")
    if _config_string(to_save, "config_title_regex"):
        calibre_db.create_functions(config)

    if not check_valid_read_column(to_save.get("config_read_column", "0")):
        flash(_("Invalid Read Column"), category="error")
        log.debug("Invalid Read column")
        return view_configuration()
    _config_int(to_save, "config_read_column")

    if not check_valid_restricted_column(to_save.get("config_restricted_column", "0")):
        flash(_("Invalid Restricted Column"), category="error")
        log.debug("Invalid Restricted Column")
        return view_configuration()
    _config_int(to_save, "config_restricted_column")

    _config_int(to_save, "config_theme")
    _config_int(to_save, "config_random_books")
    _config_int(to_save, "config_books_per_page")
    _config_int(to_save, "config_authors_max")
    _config_string(to_save, "config_default_language")
    _config_string(to_save, "config_default_locale")

    config.config_default_role = constants.selected_roles(to_save)
    config.config_default_role &= ~constants.ROLE_ANONYMOUS

    config.config_default_show = sum(int(k[5:]) for k in to_save if k.startswith('show_'))
    if "Show_detail_random" in to_save:
        config.config_default_show |= constants.DETAIL_RANDOM

    config.save()
    flash(_("Calibre-Web Automated configuration updated"), category="success")
    log.debug("Calibre-Web Automated configuration updated")
    before_request()

    return view_configuration()


@admi.route("/ajax/loaddialogtexts/<element_id>", methods=['POST'])
@user_login_required
def load_dialogtexts(element_id):
    texts = {"header": "", "main": "", "valid": 1}
    if element_id == "config_delete_kobo_token":
        texts["main"] = _('Do you really want to delete the Kobo Token?')
    elif element_id == "btndeletedomain":
        texts["main"] = _('Do you really want to delete this domain?')
    elif element_id == "btndeluser":
        texts["main"] = _('Do you really want to delete this user?')
    elif element_id == "delete_shelf":
        texts["main"] = _('Are you sure you want to delete this shelf?')
    elif element_id == "select_locale":
        texts["main"] = _('Are you sure you want to change locales of selected user(s)?')
    elif element_id == "select_default_language":
        texts["main"] = _('Are you sure you want to change visible book languages for selected user(s)?')
    elif element_id == "role":
        texts["main"] = _('Are you sure you want to change the selected role for the selected user(s)?')
    elif element_id == "restrictions":
        texts["main"] = _('Are you sure you want to change the selected restrictions for the selected user(s)?')
    elif element_id == "sidebar_view":
        texts["main"] = _('Are you sure you want to change the selected visibility restrictions '
                          'for the selected user(s)?')
    elif element_id == "kobo_only_shelves_sync":
        texts["main"] = _('Are you sure you want to change shelf sync behavior for the selected user(s)?')
    elif element_id == "db_submit":
        texts["main"] = _('Are you sure you want to change Calibre library location?')
    elif element_id == "admin_refresh_cover_cache":
        texts["main"] = _('Calibre-Web Automated will search for updated Covers '
                          'and update Cover Thumbnails, this may take a while?')
    elif element_id == "btnfullsync":
        texts["main"] = _("Are you sure you want delete Calibre-Web Automated's sync database "
                          "to force a full sync with your Kobo Reader?")
    return json.dumps(texts)


@admi.route("/ajax/editdomain/<int:allow>", methods=['POST'])
@user_login_required
@admin_required
def edit_domain(allow):
    # POST /post
    # name:  'username',  //name of field (column in db)
    # pk:    1            //primary key (record id)
    # value: 'superuser!' //new value
    vals = request.form.to_dict()
    answer = ub.session.query(ub.Registration).filter(ub.Registration.id == vals['pk']).first()
    answer.domain = vals['value'].replace('*', '%').replace('?', '_').lower()
    return ub.session_commit("Registering Domains edited {}".format(answer.domain))


@admi.route("/ajax/adddomain/<int:allow>", methods=['POST'])
@user_login_required
@admin_required
def add_domain(allow):
    domain_name = request.form.to_dict()['domainname'].replace('*', '%').replace('?', '_').lower()
    check = ub.session.query(ub.Registration).filter(ub.Registration.domain == domain_name) \
        .filter(ub.Registration.allow == allow).first()
    if not check:
        new_domain = ub.Registration(domain=domain_name, allow=allow)
        ub.session.add(new_domain)
        ub.session_commit("Registering Domains added {}".format(domain_name))
    return ""


@admi.route("/ajax/deletedomain", methods=['POST'])
@user_login_required
@admin_required
def delete_domain():
    try:
        domain_id = request.form.to_dict()['domainid'].replace('*', '%').replace('?', '_').lower()
        ub.session.query(ub.Registration).filter(ub.Registration.id == domain_id).delete()
        ub.session_commit("Registering Domains deleted {}".format(domain_id))
        # If last domain was deleted, add all domains by default
        if not ub.session.query(ub.Registration).filter(ub.Registration.allow == 1).count():
            new_domain = ub.Registration(domain="%.%", allow=1)
            ub.session.add(new_domain)
            ub.session_commit("Last Registering Domain deleted, added *.* as default")
    except KeyError:
        pass
    return ""


@admi.route("/ajax/domainlist/<int:allow>")
@user_login_required
@admin_required
def list_domain(allow):
    answer = ub.session.query(ub.Registration).filter(ub.Registration.allow == allow).all()
    json_dumps = json.dumps([{"domain": r.domain.replace('%', '*').replace('_', '?'), "id": r.id} for r in answer])
    js = json.dumps(json_dumps.replace('"', "'")).strip('"')
    response = make_response(js.replace("'", '"'))
    response.headers["Content-Type"] = "application/json; charset=utf-8"
    return response


@admi.route("/ajax/editrestriction/<int:res_type>", defaults={"user_id": 0}, methods=['POST'])
@admi.route("/ajax/editrestriction/<int:res_type>/<int:user_id>", methods=['POST'])
@user_login_required
@admin_required
def edit_restriction(res_type, user_id):
    element = request.form.to_dict()
    if element['id'].startswith('a'):
        if res_type == 0:  # Tags as template
            elementlist = config.list_allowed_tags()
            elementlist[int(element['id'][1:])] = element['Element']
            config.config_allowed_tags = ','.join(elementlist)
            config.save()
        if res_type == 1:  # CustomC
            elementlist = config.list_allowed_column_values()
            elementlist[int(element['id'][1:])] = element['Element']
            config.config_allowed_column_value = ','.join(elementlist)
            config.save()
        if res_type == 2:  # Tags per user
            if isinstance(user_id, int):
                usr = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first()
            else:
                usr = current_user
            elementlist = usr.list_allowed_tags()
            elementlist[int(element['id'][1:])] = element['Element']
            usr.allowed_tags = ','.join(elementlist)
            ub.session_commit("Changed allowed tags of user {} to {}".format(usr.name, usr.allowed_tags))
        if res_type == 3:  # CColumn per user
            if isinstance(user_id, int):
                usr = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first()
            else:
                usr = current_user
            elementlist = usr.list_allowed_column_values()
            elementlist[int(element['id'][1:])] = element['Element']
            usr.allowed_column_value = ','.join(elementlist)
            ub.session_commit("Changed allowed columns of user {} to {}".format(usr.name, usr.allowed_column_value))
    if element['id'].startswith('d'):
        if res_type == 0:  # Tags as template
            elementlist = config.list_denied_tags()
            elementlist[int(element['id'][1:])] = element['Element']
            config.config_denied_tags = ','.join(elementlist)
            config.save()
        if res_type == 1:  # CustomC
            elementlist = config.list_denied_column_values()
            elementlist[int(element['id'][1:])] = element['Element']
            config.config_denied_column_value = ','.join(elementlist)
            config.save()
        if res_type == 2:  # Tags per user
            if isinstance(user_id, int):
                usr = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first()
            else:
                usr = current_user
            elementlist = usr.list_denied_tags()
            elementlist[int(element['id'][1:])] = element['Element']
            usr.denied_tags = ','.join(elementlist)
            ub.session_commit("Changed denied tags of user {} to {}".format(usr.name, usr.denied_tags))
        if res_type == 3:  # CColumn per user
            if isinstance(user_id, int):
                usr = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first()
            else:
                usr = current_user
            elementlist = usr.list_denied_column_values()
            elementlist[int(element['id'][1:])] = element['Element']
            usr.denied_column_value = ','.join(elementlist)
            ub.session_commit("Changed denied columns of user {} to {}".format(usr.name, usr.denied_column_value))
    return ""


@admi.route("/ajax/addrestriction/<int:res_type>", methods=['POST'])
@user_login_required
@admin_required
def add_user_0_restriction(res_type):
    return add_restriction(res_type, 0)


@admi.route("/ajax/addrestriction/<int:res_type>/<int:user_id>", methods=['POST'])
@user_login_required
@admin_required
def add_restriction(res_type, user_id):
    element = request.form.to_dict()
    if res_type == 0:  # Tags as template
        if 'submit_allow' in element:
            config.config_allowed_tags = restriction_addition(element, config.list_allowed_tags)
            config.save()
        elif 'submit_deny' in element:
            config.config_denied_tags = restriction_addition(element, config.list_denied_tags)
            config.save()
    if res_type == 1:  # CCustom as template
        if 'submit_allow' in element:
            config.config_allowed_column_value = restriction_addition(element, config.list_allowed_column_values)
            config.save()
        elif 'submit_deny' in element:
            config.config_denied_column_value = restriction_addition(element, config.list_denied_column_values)
            config.save()
    if res_type == 2:  # Tags per user
        if isinstance(user_id, int):
            usr = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first()
        else:
            usr = current_user
        if 'submit_allow' in element:
            usr.allowed_tags = restriction_addition(element, usr.list_allowed_tags)
            ub.session_commit("Changed allowed tags of user {} to {}".format(usr.name, usr.list_allowed_tags()))
        elif 'submit_deny' in element:
            usr.denied_tags = restriction_addition(element, usr.list_denied_tags)
            ub.session_commit("Changed denied tags of user {} to {}".format(usr.name, usr.list_denied_tags()))
    if res_type == 3:  # CustomC per user
        if isinstance(user_id, int):
            usr = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first()
        else:
            usr = current_user
        if 'submit_allow' in element:
            usr.allowed_column_value = restriction_addition(element, usr.list_allowed_column_values)
            ub.session_commit("Changed allowed columns of user {} to {}".format(usr.name, usr.list_allowed_column_values()))
        elif 'submit_deny' in element:
            usr.denied_column_value = restriction_addition(element, usr.list_denied_column_values)
            ub.session_commit("Changed denied columns of user {} to {}".format(usr.name, usr.list_denied_column_values()))
    return ""


@admi.route("/ajax/deleterestriction/<int:res_type>", methods=['POST'])
@user_login_required
@admin_required
def delete_user_0_restriction(res_type):
    return delete_restriction(res_type, 0)


@admi.route("/ajax/deleterestriction/<int:res_type>/<int:user_id>", methods=['POST'])
@user_login_required
@admin_required
def delete_restriction(res_type, user_id):
    element = request.form.to_dict()
    if res_type == 0:  # Tags as template
        if element['id'].startswith('a'):
            config.config_allowed_tags = restriction_deletion(element, config.list_allowed_tags)
            config.save()
        elif element['id'].startswith('d'):
            config.config_denied_tags = restriction_deletion(element, config.list_denied_tags)
            config.save()
    elif res_type == 1:  # CustomC as template
        if element['id'].startswith('a'):
            config.config_allowed_column_value = restriction_deletion(element, config.list_allowed_column_values)
            config.save()
        elif element['id'].startswith('d'):
            config.config_denied_column_value = restriction_deletion(element, config.list_denied_column_values)
            config.save()
    elif res_type == 2:  # Tags per user
        if isinstance(user_id, int):
            usr = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first()
        else:
            usr = current_user
        if element['id'].startswith('a'):
            usr.allowed_tags = restriction_deletion(element, usr.list_allowed_tags)
            ub.session_commit("Deleted allowed tags of user {}: {}".format(usr.name, element['Element']))
        elif element['id'].startswith('d'):
            usr.denied_tags = restriction_deletion(element, usr.list_denied_tags)
            ub.session_commit("Deleted denied tag of user {}: {}".format(usr.name, element['Element']))
    elif res_type == 3:  # Columns per user
        if isinstance(user_id, int):
            usr = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first()
        else:
            usr = current_user
        if element['id'].startswith('a'):
            usr.allowed_column_value = restriction_deletion(element, usr.list_allowed_column_values)
            ub.session_commit("Deleted allowed columns of user {}: {}".format(usr.name, usr.list_allowed_column_values()))

        elif element['id'].startswith('d'):
            usr.denied_column_value = restriction_deletion(element, usr.list_denied_column_values)
            ub.session_commit("Deleted denied columns of user {}: {}".format(usr.name, usr.list_denied_column_values()))
    return ""


@admi.route("/ajax/listrestriction/<int:res_type>", defaults={"user_id": 0})
@admi.route("/ajax/listrestriction/<int:res_type>/<int:user_id>")
@user_login_required
@admin_required
def list_restriction(res_type, user_id):
    if res_type == 0:  # Tags as template
        restrict = [{'Element': x, 'type': _('Deny'), 'id': 'd' + str(i)}
                    for i, x in enumerate(config.list_denied_tags()) if x != '']
        allow = [{'Element': x, 'type': _('Allow'), 'id': 'a' + str(i)}
                 for i, x in enumerate(config.list_allowed_tags()) if x != '']
        json_dumps = restrict + allow
    elif res_type == 1:  # CustomC as template
        restrict = [{'Element': x, 'type': _('Deny'), 'id': 'd' + str(i)}
                    for i, x in enumerate(config.list_denied_column_values()) if x != '']
        allow = [{'Element': x, 'type': _('Allow'), 'id': 'a' + str(i)}
                 for i, x in enumerate(config.list_allowed_column_values()) if x != '']
        json_dumps = restrict + allow
    elif res_type == 2:  # Tags per user
        if isinstance(user_id, int):
            usr = ub.session.query(ub.User).filter(ub.User.id == user_id).first()
        else:
            usr = current_user
        restrict = [{'Element': x, 'type': _('Deny'), 'id': 'd' + str(i)}
                    for i, x in enumerate(usr.list_denied_tags()) if x != '']
        allow = [{'Element': x, 'type': _('Allow'), 'id': 'a' + str(i)}
                 for i, x in enumerate(usr.list_allowed_tags()) if x != '']
        json_dumps = restrict + allow
    elif res_type == 3:  # CustomC per user
        if isinstance(user_id, int):
            usr = ub.session.query(ub.User).filter(ub.User.id == user_id).first()
        else:
            usr = current_user
        restrict = [{'Element': x, 'type': _('Deny'), 'id': 'd' + str(i)}
                    for i, x in enumerate(usr.list_denied_column_values()) if x != '']
        allow = [{'Element': x, 'type': _('Allow'), 'id': 'a' + str(i)}
                 for i, x in enumerate(usr.list_allowed_column_values()) if x != '']
        json_dumps = restrict + allow
    else:
        json_dumps = ""
    js = json.dumps(json_dumps)
    response = make_response(js)
    response.headers["Content-Type"] = "application/json; charset=utf-8"
    return response


@admi.route("/ajax/fullsync", methods=["POST"])
@user_login_required
def ajax_self_fullsync():
    return do_full_kobo_sync(current_user.id)


@admi.route("/ajax/fullsync/<int:userid>", methods=["POST"])
@user_login_required
@admin_required
def ajax_fullsync(userid):
    return do_full_kobo_sync(userid)


@admi.route("/ajax/pathchooser/")
@user_login_required
@admin_required
def ajax_pathchooser():
    return pathchooser()


def do_full_kobo_sync(userid):
    count = ub.session.query(ub.KoboSyncedBooks).filter(userid == ub.KoboSyncedBooks.user_id).delete()
    message = _("{} sync entries deleted").format(count)
    ub.session_commit(message)
    return Response(json.dumps([{"type": "success", "message": message}]), mimetype='application/json')


def check_valid_read_column(column):
    if column != "0":
        if not calibre_db.session.query(db.CustomColumns).filter(db.CustomColumns.id == column) \
          .filter(and_(db.CustomColumns.datatype == 'bool', db.CustomColumns.mark_for_delete == 0)).all():
            return False
    return True


def check_valid_restricted_column(column):
    if column != "0":
        if not calibre_db.session.query(db.CustomColumns).filter(db.CustomColumns.id == column) \
          .filter(and_(db.CustomColumns.datatype == 'text', db.CustomColumns.mark_for_delete == 0)).all():
            return False
    return True


def restriction_addition(element, list_func):
    elementlist = list_func()
    if elementlist == ['']:
        elementlist = []
    if not element['add_element'] in elementlist:
        elementlist += [element['add_element']]
    return ','.join(elementlist)


def restriction_deletion(element, list_func):
    elementlist = list_func()
    if element['Element'] in elementlist:
        elementlist.remove(element['Element'])
    return ','.join(elementlist)


def prepare_tags(user, action, tags_name, id_list):
    if "tags" in tags_name:
        tags = calibre_db.session.query(db.Tags).filter(db.Tags.id.in_(id_list)).all()
        if not tags:
            raise Exception(_("Tag not found"))
        new_tags_list = [x.name for x in tags]
    else:
        try:
            tags = calibre_db.session.query(db.cc_classes[config.config_restricted_column]) \
                .filter(db.cc_classes[config.config_restricted_column].id.in_(id_list)).all()
        except (KeyError, AttributeError, IndexError):
            log.error("Custom Column No.{} does not exist in calibre database".format(
                config.config_restricted_column))
            raise Exception(_("Custom Column No.%(column)d does not exist in calibre database",
                    column=config.config_restricted_column))
        new_tags_list = [x.value for x in tags]
    saved_tags_list = user.__dict__[tags_name].split(",") if len(user.__dict__[tags_name]) else []
    if action == "remove":
        saved_tags_list = [x for x in saved_tags_list if x not in new_tags_list]
    elif action == "add":
        saved_tags_list.extend(x for x in new_tags_list if x not in saved_tags_list)
    else:
        raise Exception(_("Invalid Action"))
    return ",".join(saved_tags_list)


def get_drives(current):
    drive_letters = []
    for d in string.ascii_uppercase:
        if os.path.exists('{}:'.format(d)) and current[0].lower() != d.lower():
            drive = "{}:\\".format(d)
            data = {"name": drive, "fullpath": drive, "type": "dir", "size": "", "sort": "_" + drive.lower()}
            drive_letters.append(data)
    return drive_letters


def pathchooser():
    browse_for = "folder"
    folder_only = request.args.get('folder', False) == "true"
    file_filter = request.args.get('filter', "")
    path = os.path.normpath(request.args.get('path', ""))

    if os.path.isfile(path):
        old_file = path
        path = os.path.dirname(path)
    else:
        old_file = ""

    absolute = False

    if os.path.isdir(path):
        cwd = os.path.realpath(path)
        absolute = True
    else:
        cwd = os.getcwd()

    cwd = os.path.normpath(os.path.realpath(cwd))
    parent_dir = os.path.dirname(cwd)
    if not absolute:
        if os.path.realpath(cwd) == os.path.realpath("/"):
            cwd = os.path.relpath(cwd)
        else:
            cwd = os.path.relpath(cwd) + os.path.sep
        parent_dir = os.path.relpath(parent_dir) + os.path.sep

    files = []
    if os.path.realpath(cwd) == os.path.realpath("/") \
            or (sys.platform == "win32" and os.path.realpath(cwd)[1:] == os.path.realpath("/")[1:]):
        # we are in root
        parent_dir = ""
        if sys.platform == "win32":
            files = get_drives(cwd)

    try:
        folders = os.listdir(cwd)
    except Exception:
        folders = []

    for f in folders:
        try:
            sanitized_f = str(Markup.escape(f))
            data = {"name": sanitized_f, "fullpath": os.path.join(cwd, sanitized_f)}
            data["sort"] = data["fullpath"].lower()
        except Exception:
            continue

        if os.path.isfile(os.path.join(cwd, f)):
            if folder_only:
                continue
            if file_filter != "" and file_filter != f:
                continue
            data["type"] = "file"
            data["size"] = os.path.getsize(os.path.join(cwd, f))

            power = 0
            while (data["size"] >> 10) > 0.3:
                power += 1
                data["size"] >>= 10
            units = ("", "K", "M", "G", "T")
            data["size"] = str(data["size"]) + " " + units[power] + "Byte"
        else:
            data["type"] = "dir"
            data["size"] = ""

        files.append(data)

    files = sorted(files, key=operator.itemgetter("type", "sort"))

    context = {
        "cwd": cwd,
        "files": files,
        "parentdir": parent_dir,
        "type": browse_for,
        "oldfile": old_file,
        "absolute": absolute,
    }
    return json.dumps(context)


def _config_int(to_save, x, func=int):
    return config.set_from_dictionary(to_save, x, func)


def _config_checkbox(to_save, x):
    return config.set_from_dictionary(to_save, x, lambda y: y == "on", False)


def _config_checkbox_int(to_save, x):
    return config.set_from_dictionary(to_save, x, lambda y: 1 if (y == "on") else 0, 0)


def _config_string(to_save, x):
    return config.set_from_dictionary(to_save, x, lambda y: strip_whitespaces(y) if y else y)


def _configuration_gdrive_helper(to_save):
    gdrive_error = None
    if to_save.get("config_use_google_drive"):
        gdrive_secrets = {}

        if not os.path.isfile(gdriveutils.SETTINGS_YAML):
            config.config_use_google_drive = False

        if gdrive_support:
            gdrive_error = gdriveutils.get_error_text(gdrive_secrets)
        if "config_use_google_drive" in to_save and not config.config_use_google_drive and not gdrive_error:
            with open(gdriveutils.CLIENT_SECRETS, 'r') as settings:
                gdrive_secrets = json.load(settings)['web']
            if not gdrive_secrets:
                return _configuration_result(_('client_secrets.json Is Not Configured For Web Application'))
            gdriveutils.update_settings(
                gdrive_secrets['client_id'],
                gdrive_secrets['client_secret'],
                gdrive_secrets['redirect_uris'][0]
            )

    # always show Google Drive settings, but in case of error deny support
    new_gdrive_value = (not gdrive_error) and ("config_use_google_drive" in to_save)
    if config.config_use_google_drive and not new_gdrive_value:
        config.config_google_drive_watch_changes_response = {}
    config.config_use_google_drive = new_gdrive_value
    if _config_string(to_save, "config_google_drive_folder"):
        gdriveutils.deleteDatabaseOnChange()
    return gdrive_error


def _configuration_oauth_helper(to_save):
    reboot_required = False

    for element in oauthblueprints:
        update = {}
        if element["provider_name"] == "generic":
            if to_save["config_generic_oauth_client_id"] != element["oauth_client_id"]:
                reboot_required = True
                update["oauth_client_id"] = to_save["config_generic_oauth_client_id"]
            if to_save["config_generic_oauth_client_secret"] != element["oauth_client_secret"]:
                reboot_required = True
                update["oauth_client_secret"] = to_save["config_generic_oauth_client_secret"]

            # Handle metadata URL (takes precedence over manual configuration)
            metadata_url = to_save.get("config_generic_oauth_metadata_url", "")
            if metadata_url != element.get("metadata_url", ""):
                reboot_required = True
                update["metadata_url"] = metadata_url

                # If metadata URL is provided, try to fetch endpoints
                if metadata_url:
                    try:
                        resp = requests.get(metadata_url, timeout=3, verify=constants.OAUTH_SSL_STRICT)
                        if resp.status_code == 200:
                            data = resp.json()
                            update["oauth_base_url"] = data.get("issuer", "")
                            update["oauth_authorize_url"] = data.get("authorization_endpoint", "")
                            update["oauth_token_url"] = data.get("token_endpoint", "")
                            update["oauth_userinfo_url"] = data.get("userinfo_endpoint", "")
                        else:
                            log.warning(f"Failed to fetch OAuth metadata: HTTP {resp.status_code}")
                    except requests.exceptions.Timeout:
                        log.warning("OAuth metadata fetch timed out - configuration saved but endpoints not auto-discovered")
                    except requests.exceptions.RequestException as ex:
                        log.warning(f"Failed to fetch OAuth metadata: {ex}")
                    except Exception as ex:
                        log.error(f"Unexpected error fetching OAuth metadata: {ex}")

            # Handle manual server URL (fallback or override)
            elif to_save["config_generic_oauth_server_url"] != element["oauth_base_url"]:
                reboot_required = True
                update["oauth_base_url"] = to_save["config_generic_oauth_server_url"]
                try:
                    resp = requests.get(
                        os.path.join(update["oauth_base_url"], ".well-known/openid-configuration"),
                        timeout=3,
                        verify=constants.OAUTH_SSL_STRICT
                    )
                    if resp.status_code == 200:
                        data = resp.json()
                        update["oauth_authorize_url"] = data.get("authorization_endpoint", "")
                        update["oauth_token_url"] = data.get("token_endpoint", "")
                        update["oauth_userinfo_url"] = data.get("userinfo_endpoint", "")
                    else:
                        log.warning(f"Failed to fetch OIDC configuration: HTTP {resp.status_code}")
                except requests.exceptions.Timeout:
                    log.warning("OIDC configuration fetch timed out - configuration saved but endpoints not auto-discovered")
                except requests.exceptions.RequestException as ex:
                    log.warning(f"Failed to fetch OIDC configuration: {ex}")
                except Exception as ex:
                    log.error(f"Unexpected error fetching OIDC configuration: {ex}")

            # Handle manual endpoint URLs if metadata URL is not used
            if not metadata_url:
                # Map form field names to database field names
                endpoint_mappings = {
                    "config_generic_oauth_auth_url": "oauth_authorize_url",
                    "config_generic_oauth_token_url": "oauth_token_url",
                    "config_generic_oauth_userinfo_url": "oauth_userinfo_url"
                }

                for form_field, db_field in endpoint_mappings.items():
                    if form_field in to_save and to_save[form_field] != element.get(db_field, ""):
                        reboot_required = True
                        update[db_field] = to_save[form_field]

            # Handle scope
            if to_save.get("config_generic_oauth_scope", "") != element.get("scope", ""):
                reboot_required = True
                update["scope"] = to_save.get("config_generic_oauth_scope", "")

            # Handle username mapper
            if to_save.get("config_generic_oauth_username_mapper", "") != element.get("username_mapper", ""):
                reboot_required = True
                update["username_mapper"] = to_save.get("config_generic_oauth_username_mapper", "")

            # Handle email mapper
            if to_save.get("config_generic_oauth_email_mapper", "") != element.get("email_mapper", ""):
                reboot_required = True
                update["email_mapper"] = to_save.get("config_generic_oauth_email_mapper", "")

            # Handle login button text
            if to_save.get("config_generic_oauth_login_button", "") != element.get("login_button", ""):
                reboot_required = True
                update["login_button"] = to_save.get("config_generic_oauth_login_button", "")

            if to_save["config_generic_oauth_admin_group"] != element["oauth_admin_group"]:
                reboot_required = True
                update["oauth_admin_group"] = to_save["config_generic_oauth_admin_group"]
        else:
            if to_save["config_" + str(element['id']) + "_oauth_client_id"] != element["oauth_client_id"]:
                reboot_required = True
                update["oauth_client_id"] = to_save["config_" + str(element['id']) + "_oauth_client_id"]
            if to_save["config_" + str(element['id']) + "_oauth_client_secret"] != element["oauth_client_secret"]:
                reboot_required = True
                update["oauth_client_secret"] = to_save["config_" + str(element['id']) + "_oauth_client_secret"]

        oauth_client_id = update.get("oauth_client_id", element["oauth_client_id"])
        oauth_client_secret = update.get("oauth_client_secret", element["oauth_client_secret"])
        update["active"] = 1 if oauth_client_id and oauth_client_secret else 0

        ub.session.query(ub.OAuthProvider).filter(ub.OAuthProvider.id == element['id']).update(update)

    return reboot_required, None


def _configuration_logfile_helper(to_save):
    reboot_required = False
    reboot_required |= _config_int(to_save, "config_log_level")
    reboot_required |= _config_string(to_save, "config_logfile")
    if not logger.is_valid_logfile(config.config_logfile):
        return reboot_required, \
               _configuration_result(_('Logfile Location is not Valid, Please Enter Correct Path'))

    reboot_required |= _config_checkbox_int(to_save, "config_access_log")
    reboot_required |= _config_string(to_save, "config_access_logfile")
    if not logger.is_valid_logfile(config.config_access_logfile):
        return reboot_required, \
               _configuration_result(_('Access Logfile Location is not Valid, Please Enter Correct Path'))
    return reboot_required, None


def _configuration_ldap_helper(to_save):
    reboot_required = False
    reboot_required |= _config_int(to_save, "config_ldap_port")
    reboot_required |= _config_int(to_save, "config_ldap_authentication")
    reboot_required |= _config_string(to_save, "config_ldap_dn")
    reboot_required |= _config_string(to_save, "config_ldap_serv_username")
    reboot_required |= _config_string(to_save, "config_ldap_user_object")
    reboot_required |= _config_string(to_save, "config_ldap_group_object_filter")
    reboot_required |= _config_string(to_save, "config_ldap_group_members_field")
    reboot_required |= _config_string(to_save, "config_ldap_member_user_object")
    reboot_required |= _config_checkbox(to_save, "config_ldap_openldap")
    _config_checkbox(to_save, "config_ldap_auto_create_users")
    reboot_required |= _config_int(to_save, "config_ldap_encryption")
    reboot_required |= _config_string(to_save, "config_ldap_cacert_path")
    reboot_required |= _config_string(to_save, "config_ldap_cert_path")
    reboot_required |= _config_string(to_save, "config_ldap_key_path")
    _config_string(to_save, "config_ldap_group_name")

    address = urlparse(to_save.get("config_ldap_provider_url", ""))
    to_save["config_ldap_provider_url"] = (address.hostname or address.path).strip("/")
    reboot_required |= _config_string(to_save, "config_ldap_provider_url")

    if to_save.get("config_ldap_serv_password_e", "") != "":
        reboot_required |= 1
        config.set_from_dictionary(to_save, "config_ldap_serv_password_e")
    config.save()

    if not config.config_ldap_provider_url \
      or not config.config_ldap_port \
      or not config.config_ldap_dn \
      or not config.config_ldap_user_object:
        return reboot_required, _configuration_result(_('Please Enter a LDAP Provider, '
                                                        'Port, DN and User Object Identifier'))

    if config.config_ldap_authentication > constants.LDAP_AUTH_ANONYMOUS:
        if config.config_ldap_authentication > constants.LDAP_AUTH_UNAUTHENTICATE:
            if not config.config_ldap_serv_username or not bool(config.config_ldap_serv_password_e):
                return reboot_required, _configuration_result(_('Please Enter a LDAP Service Account and Password'))
        else:
            if not config.config_ldap_serv_username:
                return reboot_required, _configuration_result(_('Please Enter a LDAP Service Account'))

    if config.config_ldap_group_object_filter:
        if config.config_ldap_group_object_filter.count("%s") != 1:
            return reboot_required, \
                   _configuration_result(_('LDAP Group Object Filter Needs to Have One "%s" Format Identifier'))
        if config.config_ldap_group_object_filter.count("(") != config.config_ldap_group_object_filter.count(")"):
            return reboot_required, _configuration_result(_('LDAP Group Object Filter Has Unmatched Parenthesis'))

    if config.config_ldap_user_object.count("%s") != 1:
        return reboot_required, \
               _configuration_result(_('LDAP User Object Filter needs to Have One "%s" Format Identifier'))
    if config.config_ldap_user_object.count("(") != config.config_ldap_user_object.count(")"):
        return reboot_required, _configuration_result(_('LDAP User Object Filter Has Unmatched Parenthesis'))

    if to_save.get("ldap_import_user_filter") == '0':
        config.config_ldap_member_user_object = ""
    else:
        if config.config_ldap_member_user_object.count("%s") != 1:
            return reboot_required, \
                   _configuration_result(_('LDAP Member User Filter needs to Have One "%s" Format Identifier'))
        if config.config_ldap_member_user_object.count("(") != config.config_ldap_member_user_object.count(")"):
            return reboot_required, _configuration_result(_('LDAP Member User Filter Has Unmatched Parenthesis'))

    if config.config_ldap_cacert_path or config.config_ldap_cert_path or config.config_ldap_key_path:
        if not (os.path.isfile(config.config_ldap_cacert_path) and
                os.path.isfile(config.config_ldap_cert_path) and
                os.path.isfile(config.config_ldap_key_path)):
            return reboot_required, \
                   _configuration_result(_('LDAP CACertificate, Certificate or Key Location is not Valid, '
                                           'Please Enter Correct Path'))
    return reboot_required, None


@admi.route("/ajax/simulatedbchange", methods=['POST'])
@user_login_required
@admin_required
def simulatedbchange():
    db_change, db_valid = _db_simulate_change()
    return Response(json.dumps({"change": db_change, "valid": db_valid}), mimetype='application/json')


@admi.route("/admin/user/new", methods=["GET", "POST"])
@user_login_required
@admin_required
def new_user():
    content = ub.User()
    languages = calibre_db.speaking_language()
    translations = get_available_locale()
    kobo_support = feature_support['kobo'] and config.config_kobo_sync
    if request.method == "POST":
        to_save = request.form.to_dict()
        _handle_new_user(to_save, content, languages, translations, kobo_support)
    else:
        content.role = config.config_default_role
        content.sidebar_view = config.config_default_show
        content.locale = config.config_default_locale
        content.default_language = config.config_default_language
    return render_title_template("user_edit.html", new_user=1, content=content,
                                 config=config, translations=translations,
                                 languages=languages, title=_("Add New User"), page="newuser",
                                 kobo_support=kobo_support, registered_oauth=oauth_check)


@admi.route("/admin/mailsettings", methods=["GET"])
@user_login_required
@admin_required
def edit_mailsettings():
    content = config.get_mail_settings()
    return render_title_template("email_edit.html", content=content, title=_("Edit Email Server Settings"),
                                 page="mailset", feature_support=feature_support)


@admi.route("/admin/mailsettings", methods=["POST"])
@user_login_required
@admin_required
def update_mailsettings():
    to_save = request.form.to_dict()
    _config_int(to_save, "mail_server_type")
    if to_save.get("invalidate"):
        config.mail_gmail_token = {}
        try:
            flag_modified(config, "mail_gmail_token")
        except AttributeError:
            pass
    elif to_save.get("gmail"):
        try:
            config.mail_gmail_token = services.gmail.setup_gmail(config.mail_gmail_token)
            flash(_("Success! Gmail Account Verified."), category="success")
        except Exception as ex:
            flash(str(ex), category="error")
            log.error(ex)
            return edit_mailsettings()

    else:
        _config_int(to_save, "mail_port")
        _config_int(to_save, "mail_use_ssl")
        if to_save.get("mail_password_e", ""):
            _config_string(to_save, "mail_password_e")
        _config_int(to_save, "mail_size", lambda y: int(y) * 1024 * 1024)
        config.mail_server = strip_whitespaces(to_save.get('mail_server', ""))
        config.mail_from = strip_whitespaces(to_save.get('mail_from', ""))
        config.mail_login = strip_whitespaces(to_save.get('mail_login', ""))
    try:
        config.save()
    except (OperationalError, InvalidRequestError) as e:
        ub.session.rollback()
        log.error_or_exception("Settings Database error: {}".format(e))
        flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
        return edit_mailsettings()
    except Exception as e:
        flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
        return edit_mailsettings()

    if to_save.get("test"):
        if current_user.email:
            result = send_test_mail(current_user.email, current_user.name)
            if result is None:
                flash(_("Test e-mail queued for sending to %(email)s, please check Tasks for result",
                        email=current_user.email), category="info")
            else:
                flash(_("There was an error sending the Test e-mail: %(res)s", res=result), category="error")
        else:
            flash(_("Please configure your e-mail address first..."), category="error")
    else:
        flash(_("Email Server Settings updated"), category="success")

    return edit_mailsettings()


@admi.route("/admin/scheduledtasks")
@user_login_required
@admin_required
def edit_scheduledtasks():
    content = config.get_scheduled_task_settings()
    time_field = list()
    duration_field = list()

    for n in range(24):
        time_field.append((n, format_time(datetime_time(hour=n), format="short", )))
    for n in range(5, 65, 5):
        t = timedelta(hours=n // 60, minutes=n % 60)
        duration_field.append((n, format_timedelta(t, threshold=.97)))

    return render_title_template("schedule_edit.html",
                                 config=content,
                                 starttime=time_field,
                                 duration=duration_field,
                                 title=_("Edit Scheduled Tasks Settings"))


@admi.route("/admin/scheduledtasks", methods=["POST"])
@user_login_required
@admin_required
def update_scheduledtasks():
    error = False
    to_save = request.form.to_dict()
    if 0 <= int(to_save.get("schedule_start_time")) <= 23:
        _config_int(to_save, "schedule_start_time")
    else:
        flash(_("Invalid start time for task specified"), category="error")
        error = True
    if 0 < int(to_save.get("schedule_duration")) <= 60:
        _config_int(to_save, "schedule_duration")
    else:
        flash(_("Invalid duration for task specified"), category="error")
        error = True
    _config_checkbox(to_save, "schedule_generate_book_covers")
    _config_checkbox(to_save, "schedule_generate_series_covers")
    _config_checkbox(to_save, "schedule_metadata_backup")
    _config_checkbox(to_save, "schedule_reconnect")

    if not error:
        try:
            config.save()
            flash(_("Scheduled tasks settings updated"), category="success")

            # Cancel any running tasks
            schedule.end_scheduled_tasks()

            # Re-register tasks with new settings
            schedule.register_scheduled_tasks(config.schedule_reconnect)
        except IntegrityError:
            ub.session.rollback()
            log.error("An unknown error occurred while saving scheduled tasks settings")
            flash(_("Oops! An unknown error occurred. Please try again later."), category="error")
        except OperationalError:
            ub.session.rollback()
            log.error("Settings DB is not Writeable")
            flash(_("Settings DB is not Writeable"), category="error")

    return edit_scheduledtasks()


@admi.route("/admin/user/<int:user_id>", methods=["GET", "POST"])
@user_login_required
@admin_required
def edit_user(user_id):
    content = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first()  # type: ub.User
    if not content or (not config.config_anonbrowse and content.name == "Guest"):
        flash(_("User not found"), category="error")
        return redirect(url_for('admin.admin'))
    languages = calibre_db.speaking_language(return_all_languages=True)
    translations = get_available_locale()
    kobo_support = feature_support['kobo'] and config.config_kobo_sync
    if request.method == "POST":
        to_save = request.form.to_dict()
        resp = _handle_edit_user(to_save, content, languages, translations, kobo_support)
        if resp:
            return resp
    return render_title_template("user_edit.html",
                                 translations=translations,
                                 languages=languages,
                                 new_user=0,
                                 content=content,
                                 config=config,
                                 registered_oauth=oauth_check,
                                 mail_configured=config.get_mail_server_configured(),
                                 kobo_support=kobo_support,
                                 title=_("Edit User %(nick)s", nick=content.name),
                                 page="edituser")


@admi.route("/admin/resetpassword/<int:user_id>", methods=["POST"])
@user_login_required
@admin_required
def reset_user_password(user_id):
    if current_user is not None and current_user.is_authenticated:
        ret, message = reset_password(user_id)
        if ret == 1:
            log.debug("Password for user %s reset", message)
            flash(_("Success! Password for user %(user)s reset", user=message), category="success")
        elif ret == 0:
            log.error("An unknown error occurred. Please try again later.")
            flash(_("Oops! An unknown error occurred. Please try again later."), category="error")
        else:
            log.error("Please configure the SMTP mail settings.")
            flash(_("Oops! Please configure the SMTP mail settings."), category="error")
    return redirect(url_for('admin.admin'))


@admi.route("/admin/logfile")
@user_login_required
@admin_required
def view_logfile():
    logfiles = {0: logger.get_logfile(config.config_logfile),
                1: logger.get_accesslogfile(config.config_access_logfile)}
    return render_title_template("logviewer.html",
                                 title=_("Logfile viewer"),
                                 accesslog_enable=config.config_access_log,
                                 log_enable=bool(config.config_logfile != logger.LOG_TO_STDOUT),
                                 logfiles=logfiles,
                                 page="logfile")


@admi.route("/ajax/log/<int:logtype>")
@user_login_required
@admin_required
def send_logfile(logtype):
    if logtype == 1:
        logfile = logger.get_accesslogfile(config.config_access_logfile)
        return send_from_directory(os.path.dirname(logfile),
                                   os.path.basename(logfile))
    if logtype == 0:
        logfile = logger.get_logfile(config.config_logfile)
        return send_from_directory(os.path.dirname(logfile),
                                   os.path.basename(logfile))
    else:
        return ""


@admi.route("/admin/logdownload/<int:logtype>")
@user_login_required
@admin_required
def download_log(logtype):
    if logtype == 0:
        file_name = logger.get_logfile(config.config_logfile)
    elif logtype == 1:
        file_name = logger.get_accesslogfile(config.config_access_logfile)
    else:
        abort(404)
    if logger.is_valid_logfile(file_name):
        return debug_info.assemble_logfiles(file_name)
    abort(404)


@admi.route("/admin/debug")
@user_login_required
@admin_required
def download_debug():
    return debug_info.send_debug()


@admi.route("/get_update_status", methods=['GET'])
@user_login_required
@admin_required
def get_update_status():
    if feature_support['updater']:
        log.info("Update status requested")
        return updater_thread.get_available_updates(request.method)
    else:
        return ''


@admi.route("/get_updater_status", methods=['GET', 'POST'])
@user_login_required
@admin_required
def get_updater_status():
    status = {}
    if feature_support['updater']:
        if request.method == "POST":
            commit = request.form.to_dict()
            if "start" in commit and commit['start'] == 'True':
                txt = {
                    "1": _(u'Requesting update package'),
                    "2": _(u'Downloading update package'),
                    "3": _(u'Unzipping update package'),
                    "4": _(u'Replacing files'),
                    "5": _(u'Database connections are closed'),
                    "6": _(u'Stopping server'),
                    "7": _(u'Update finished, please press okay and reload page'),
                    "8": _(u'Update failed:') + u' ' + _(u'HTTP Error'),
                    "9": _(u'Update failed:') + u' ' + _(u'Connection error'),
                    "10": _(u'Update failed:') + u' ' + _(u'Timeout while establishing connection'),
                    "11": _(u'Update failed:') + u' ' + _(u'General error'),
                    "12": _(u'Update failed:') + u' ' + _(u'Update file could not be saved in temp dir'),
                    "13": _(u'Update failed:') + u' ' + _(u'Files could not be replaced during update')
                }
                status['text'] = txt
                updater_thread.status = 0
                updater_thread.resume()
                status['status'] = updater_thread.get_update_status()
        elif request.method == "GET":
            try:
                status['status'] = updater_thread.get_update_status()
                if status['status'] == -1:
                    status['status'] = 7
            except Exception:
                status['status'] = 11
        return json.dumps(status)
    return ''


def ldap_import_create_user(user, user_data):
    user_login_field = extract_dynamic_field_from_filter(user, config.config_ldap_user_object)

    try:
        username = user_data[user_login_field][0].decode('utf-8')
    except KeyError as ex:
        log.error("Failed to extract LDAP user: %s - %s", user, ex)
        message = _(u'Failed to extract at least One LDAP User')
        return 0, message

    # check for duplicate username
    if ub.session.query(ub.User).filter(func.lower(ub.User.name) == username.lower()).first():
        # if ub.session.query(ub.User).filter(ub.User.name == username).first():
        log.warning("LDAP User  %s Already in Database", user_data)
        return 0, None

    ereader_mail = ''
    if 'mail' in user_data:
        useremail = user_data['mail'][0].decode('utf-8')
        if len(user_data['mail']) > 1:
            ereader_mail = user_data['mail'][1].decode('utf-8')

    else:
        log.debug('No Mail Field Found in LDAP Response')
        useremail = username + '@email.com'

    try:
        # check for duplicate email
        useremail = check_email(useremail)
    except Exception as ex:
        log.warning("LDAP Email Error: {}, {}".format(user_data, ex))
        return 0, None
    content = ub.User()
    content.name = username
    content.password = ''  # dummy password which will be replaced by ldap one
    content.email = useremail
    content.kindle_mail = ereader_mail
    content.default_language = config.config_default_language
    content.locale = config.config_default_locale
    content.role = config.config_default_role
    content.sidebar_view = config.config_default_show
    content.allowed_tags = config.config_allowed_tags
    content.denied_tags = config.config_denied_tags
    content.allowed_column_value = config.config_allowed_column_value
    content.denied_column_value = config.config_denied_column_value
    ub.session.add(content)
    try:
        ub.session.commit()
        return 1, None  # increase no of users
    except Exception as ex:
        log.warning("Failed to create LDAP user: %s - %s", user, ex)
        ub.session.rollback()
        message = _(u'Failed to Create at Least One LDAP User')
        return 0, message


@admi.route('/import_ldap_users', methods=["POST"])
@user_login_required
@admin_required
def import_ldap_users():
    showtext = {}
    try:
        new_users = services.ldap.get_group_members(config.config_ldap_group_name)
    except (services.ldap.LDAPException, TypeError, AttributeError, KeyError) as e:
        log.error_or_exception(e)
        showtext['text'] = _(u'Error: %(ldaperror)s', ldaperror=e)
        return json.dumps(showtext)
    if not new_users:
        log.debug('LDAP empty response')
        showtext['text'] = _(u'Error: No user returned in response of LDAP server')
        return json.dumps(showtext)

    imported = 0
    for username in new_users:
        if isinstance(username, bytes):
            user = username.decode('utf-8')
        else:
            user = username
        if '=' in user:
            # if member object field is empty take user object as filter
            if config.config_ldap_member_user_object:
                query_filter = config.config_ldap_member_user_object
            else:
                query_filter = config.config_ldap_user_object
            try:
                user_identifier = extract_user_identifier(user, query_filter)
            except Exception as ex:
                log.warning(ex)
                continue
        else:
            user_identifier = user
            query_filter = None
        try:
            user_data = services.ldap.get_object_details(user=user_identifier, query_filter=query_filter)
        except AttributeError as ex:
            log.error_or_exception(ex)
            continue
        if user_data:
            user_count, message = ldap_import_create_user(user, user_data)
            if message:
                showtext['text'] = message
            else:
                imported += user_count
        else:
            log.warning("LDAP User: %s Not Found", user)
            showtext['text'] = _(u'At Least One LDAP User Not Found in Database')
    if not showtext:
        showtext['text'] = _(u'{} User Successfully Imported'.format(imported))
    return json.dumps(showtext)


@admi.route("/ajax/canceltask", methods=['POST'])
@user_login_required
@admin_required
def cancel_task():
    task_id = request.get_json().get('task_id', None)
    worker = WorkerThread.get_instance()
    worker.end_task(task_id)
    return ""


def _db_simulate_change():
    param = request.form.to_dict()
    to_save = dict()
    incoming = param.get('config_calibre_dir', config.config_calibre_dir or '')
    incoming = strip_whitespaces(re.sub(r'[\\/]metadata\.db$', '', incoming, flags=re.IGNORECASE))
    # Fallback: if nothing provided and default metadata exists, assume /calibre-library
    if not incoming and os.path.isfile('/calibre-library/metadata.db'):
        incoming = '/calibre-library'
    to_save['config_calibre_dir'] = incoming
    db_valid, db_change = calibre_db.check_valid_db(to_save["config_calibre_dir"],
                                                    ub.app_DB_path,
                                                    config.config_calibre_uuid)
    db_change = bool(db_change and config.config_calibre_dir)
    return db_change, db_valid


def _db_configuration_update_helper():
    db_change = False
    to_save = request.form.to_dict()
    gdrive_error = None

    incoming = to_save.get('config_calibre_dir', config.config_calibre_dir or '')
    incoming = re.sub(r'[\\/]metadata\.db$', '', incoming, flags=re.IGNORECASE)
    if not incoming and os.path.isfile('/calibre-library/metadata.db'):
        incoming = '/calibre-library'
    to_save['config_calibre_dir'] = incoming
    db_valid = False
    try:
        db_change, db_valid = _db_simulate_change()

        # gdrive_error drive setup
        gdrive_error = _configuration_gdrive_helper(to_save)
    except (OperationalError, InvalidRequestError) as e:
        ub.session.rollback()
        log.error_or_exception("Settings Database error: {}".format(e))
        _db_configuration_result(_("Oops! Database Error: %(error)s.", error=e.orig), gdrive_error)
    try:
        metadata_db = os.path.join(to_save['config_calibre_dir'], "metadata.db")
        if config.config_use_google_drive and is_gdrive_ready() and not os.path.exists(metadata_db):
            gdriveutils.downloadFile(None, "metadata.db", metadata_db)
            db_change = True
    except Exception as ex:
        return _db_configuration_result('{}'.format(ex), gdrive_error)
    config.config_calibre_split = to_save.get('config_calibre_split', 0) == "on"
    if config.config_calibre_split:
        split_dir = to_save.get("config_calibre_split_dir")
        if not os.path.exists(split_dir):
            return _db_configuration_result(_("Books path not valid"), gdrive_error)
        else:
            _config_string(to_save, "config_calibre_split_dir")

    if db_change or not db_valid or not config.db_configured \
      or config.config_calibre_dir != to_save["config_calibre_dir"]:
        if not os.path.exists(metadata_db) or not to_save['config_calibre_dir']:
            return _db_configuration_result(_('DB Location is not Valid, Please Enter Correct Path'), gdrive_error)
        else:
            calibre_db.setup_db(to_save['config_calibre_dir'], ub.app_DB_path)
        config.store_calibre_uuid(calibre_db, db.Library_Id)
        # if db changed -> delete shelfs, delete download books, delete read books, kobo sync...
        if db_change:
            log.info("Calibre Database changed, all Calibre-Web Automated info related to old Database gets deleted")
            ub.session.query(ub.Downloads).delete()
            ub.session.query(ub.ArchivedBook).delete()
            ub.session.query(ub.ReadBook).delete()
            ub.session.query(ub.BookShelf).delete()
            ub.session.query(ub.Bookmark).delete()
            ub.session.query(ub.KoboReadingState).delete()
            ub.session.query(ub.KoboStatistics).delete()
            ub.session.query(ub.KoboSyncedBooks).delete()
            helper.delete_thumbnail_cache()
            ub.session_commit()
            # deleted visibilities based on custom column and tags
            config.config_restricted_column = 0
            config.config_denied_tags = ""
            config.config_allowed_tags = ""
            config.config_columns_to_ignore = ""
            config.config_denied_column_value = ""
            config.config_allowed_column_value = ""
            config.config_read_column = 0
        _config_string(to_save, "config_calibre_dir")
        calibre_db.update_config(config)
        if not os.access(os.path.join(config.config_calibre_dir, "metadata.db"), os.W_OK):
            flash(_("DB is not Writeable"), category="warning")
    calibre_db.update_config(config)
    config.save()
    return _db_configuration_result(None, gdrive_error)


def _configuration_update_helper():
    reboot_required = False
    to_save = request.form.to_dict()
    try:
        reboot_required |= _config_string(to_save, "config_trustedhosts")
        reboot_required |= _config_string(to_save, "config_keyfile")
        if config.config_keyfile and not os.path.isfile(config.config_keyfile):
            return _configuration_result(_('Keyfile Location is not Valid, Please Enter Correct Path'))

        reboot_required |= _config_string(to_save, "config_certfile")
        if config.config_certfile and not os.path.isfile(config.config_certfile):
            return _configuration_result(_('Certfile Location is not Valid, Please Enter Correct Path'))

        _config_checkbox_int(to_save, "config_uploading")
        _config_checkbox_int(to_save, "config_unicode_filename")
        _config_checkbox_int(to_save, "config_embed_metadata")
        # Reboot on config_anonbrowse with enabled ldap, as decoraters are changed in this case
        reboot_required |= (_config_checkbox_int(to_save, "config_anonbrowse")
                            and config.config_login_type == constants.LOGIN_LDAP)
        _config_checkbox_int(to_save, "config_public_reg")
        _config_checkbox_int(to_save, "config_register_email")
        reboot_required |= _config_checkbox_int(to_save, "config_kobo_sync")
        _config_int(to_save, "config_external_port")
        _config_checkbox_int(to_save, "config_kobo_proxy")
        _config_checkbox_int(to_save, "config_hardcover_sync")

        if "config_upload_formats" in to_save:
            to_save["config_upload_formats"] = ','.join(
                helper.uniq([x.strip().lower() for x in to_save["config_upload_formats"].split(',')]))
            _config_string(to_save, "config_upload_formats")

        _config_string(to_save, "config_calibre")
        _config_string(to_save, "config_binariesdir")
        _config_string(to_save, "config_kepubifypath")
        if "config_binariesdir" in to_save:
            calibre_status = helper.check_calibre(config.config_binariesdir)
            if calibre_status:
                return _configuration_result(calibre_status)
            to_save["config_converterpath"] = get_calibre_binarypath("ebook-convert")
            _config_string(to_save, "config_converterpath")

        reboot_required |= _config_int(to_save, "config_login_type")

        # LDAP configurator
        if config.config_login_type == constants.LOGIN_LDAP:
            reboot, message = _configuration_ldap_helper(to_save)
            if message:
                return message
            reboot_required |= reboot

        # Remote login configuration
        _config_checkbox(to_save, "config_remote_login")
        if not config.config_remote_login:
            ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.token_type == 0).delete()

        # Goodreads configuration
        _config_checkbox(to_save, "config_use_goodreads")
        _config_string(to_save, "config_goodreads_api_key")
        if services.goodreads_support:
            services.goodreads_support.connect(config.config_goodreads_api_key,
                                               config.config_use_goodreads)

        # Hardcover configuration
        _config_checkbox(to_save, "config_hardcover_sync")
        _config_checkbox(to_save, "config_hardcover_annotations_sync")
        _config_string(to_save, "config_hardcover_token")

        _config_int(to_save, "config_updatechannel")

        # Reverse proxy login configuration
        _config_checkbox(to_save, "config_allow_reverse_proxy_header_login")
        _config_string(to_save, "config_reverse_proxy_login_header_name")
        _config_checkbox(to_save, "config_reverse_proxy_auto_create_users")

        # Validate reverse proxy configuration
        if config.config_reverse_proxy_auto_create_users and not config.config_allow_reverse_proxy_header_login:
            return _configuration_result(_('Auto-create users cannot be enabled without enabling reverse proxy authentication'))

        if config.config_reverse_proxy_auto_create_users and not config.config_reverse_proxy_login_header_name:
            return _configuration_result(_('Auto-create users requires a valid reverse proxy header name'))

        # OAuth configuration
        oauth_redirect_host_changed = False
        if "config_oauth_redirect_host" in to_save:
            old_host = getattr(config, 'config_oauth_redirect_host', '')
            new_host = to_save["config_oauth_redirect_host"].strip()

            # Validate OAuth redirect host format if provided
            if new_host:
                try:
                    # Add https:// if no scheme is provided
                    if not new_host.startswith(('http://', 'https://')):
                        new_host = f"https://{new_host}"
                        to_save["config_oauth_redirect_host"] = new_host

                    # Parse the URL to validate it
                    parsed = urlparse(new_host)
                    if not parsed.netloc:
                        return _configuration_result(_('Invalid OAuth Redirect Host format. Please include the full URL with protocol (e.g., https://your-domain.com)'))

                    # Warn if URL contains a path (could cause redirect URI issues)
                    if parsed.path and parsed.path != '/':
                        return _configuration_result(_('OAuth Redirect Host should not include a path. Use only the base URL (e.g., https://your-domain.com)'))

                except Exception:
                    return _configuration_result(_('Invalid OAuth Redirect Host format. Please include the full URL with protocol (e.g., https://your-domain.com)'))

            if old_host != new_host:
                oauth_redirect_host_changed = True

        _config_string(to_save, "config_oauth_redirect_host")

        if config.config_login_type == constants.LOGIN_OAUTH:
            reboot, message = _configuration_oauth_helper(to_save)
            if message:
                return message
            reboot_required |= reboot or oauth_redirect_host_changed

        # logfile configuration
        reboot, message = _configuration_logfile_helper(to_save)
        if message:
            return message
        reboot_required |= reboot

        # security configuration
        _config_checkbox(to_save, "config_disable_standard_login")
        _config_checkbox(to_save, "config_check_extensions")
        _config_checkbox(to_save, "config_password_policy")
        _config_checkbox(to_save, "config_password_number")
        _config_checkbox(to_save, "config_password_lower")
        _config_checkbox(to_save, "config_password_upper")
        _config_checkbox(to_save, "config_password_character")
        _config_checkbox(to_save, "config_password_special")
        if 0 < int(to_save.get("config_password_min_length", "0")) < 41:
            _config_int(to_save, "config_password_min_length")
        else:
            return _configuration_result(_('Password length has to be between 1 and 40'))
        reboot_required |= _config_int(to_save, "config_session")
        reboot_required |= _config_checkbox(to_save, "config_ratelimiter")
        reboot_required |= _config_string(to_save, "config_limiter_uri")
        reboot_required |= _config_string(to_save, "config_limiter_options")

        # Rarfile Content configuration
        _config_string(to_save, "config_rarfile_location")
        unrar_warning = None
        if "config_rarfile_location" in to_save:
            unrar_status = helper.check_unrar(config.config_rarfile_location)
            if unrar_status:
                # Store warning but don't prevent saving other settings
                unrar_warning = unrar_status
    except (OperationalError, InvalidRequestError) as e:
        ub.session.rollback()
        log.error_or_exception("Settings Database error: {}".format(e))
        _configuration_result(_("Oops! Database Error: %(error)s.", error=e.orig))

    config.save()
    if reboot_required:
        web_server.stop(True)

    return _configuration_result(None, reboot_required, unrar_warning)


def _configuration_result(error_flash=None, reboot=False, warning_flash=None):
    resp = {}
    if error_flash:
        log.error(error_flash)
        config.load()
        resp['result'] = [{'type': "danger", 'message': error_flash}]
    else:
        resp['result'] = [{'type': "success", 'message': _("Calibre-Web Automated configuration updated")}]
        # Add warning message if present (configuration was saved, but with a warning)
        if warning_flash:
            log.warning(warning_flash)
            resp['result'].append({'type': "warning", 'message': warning_flash})
    resp['reboot'] = reboot
    resp['config_upload'] = config.config_upload_formats
    return Response(json.dumps(resp), mimetype='application/json')


def _db_configuration_result(error_flash=None, gdrive_error=None):
    gdrive_authenticate = not is_gdrive_ready()
    gdrivefolders = []
    if not gdrive_error and config.config_use_google_drive:
        gdrive_error = gdriveutils.get_error_text()
    if gdrive_error and gdrive_support:
        log.error(gdrive_error)
        gdrive_error = _(gdrive_error)
        flash(gdrive_error, category="error")
    else:
        if not gdrive_authenticate and gdrive_support:
            gdrivefolders = gdriveutils.listRootFolders()
    if error_flash:
        log.error(error_flash)
        config.load()
        flash(error_flash, category="error")
    elif request.method == "POST" and not gdrive_error:
        flash(_("Database Settings updated"), category="success")

    return render_title_template("config_db.html",
                                 config=config,
                                 show_authenticate_google_drive=gdrive_authenticate,
                                 gdriveError=gdrive_error,
                                 gdrivefolders=gdrivefolders,
                                 feature_support=feature_support,
                                 title=_("Database Configuration"), page="dbconfig")


def _handle_new_user(to_save, content, languages, translations, kobo_support):
    content.default_language = to_save["default_language"]
    content.locale = to_save.get("locale", content.locale)

    content.sidebar_view = sum(int(key[5:]) for key in to_save if key.startswith('show_'))
    if "show_detail_random" in to_save:
        content.sidebar_view |= constants.DETAIL_RANDOM

    content.role = constants.selected_roles(to_save)
    # Set default theme (caliBlur = 1) for new users
    try:
        # Use global default theme config (acts as default for new users)
        content.theme = getattr(config, 'config_theme', 1)
    except Exception:
        pass
    try:
        if not to_save["name"] or not to_save["email"] or not to_save["password"]:
            log.info("Missing entries on new user")
            raise Exception(_("Oops! Please complete all fields."))
        content.password = generate_password_hash(helper.valid_password(to_save.get("password", "")))
        content.email = check_email(to_save["email"])
        # Query username, if not existing, change
        content.name = check_username(to_save["name"])
        if to_save.get("kindle_mail"):
            content.kindle_mail = valid_email(to_save["kindle_mail"])
        if config.config_public_reg and not check_valid_domain(content.email):
            log.info("E-mail: {} for new user is not from valid domain".format(content.email))
            raise Exception(_("E-mail is not from valid domain"))
    except Exception as ex:
        flash(str(ex), category="error")
        return render_title_template("user_edit.html", new_user=1, content=content,
                                     config=config,
                                     translations=translations,
                                     languages=languages, title=_("Add new user"), page="newuser",
                                     kobo_support=kobo_support, registered_oauth=oauth_check)
    try:
        content.allowed_tags = config.config_allowed_tags
        content.denied_tags = config.config_denied_tags
        content.allowed_column_value = config.config_allowed_column_value
        content.denied_column_value = config.config_denied_column_value
        # No default value for kobo sync shelf setting
        content.kobo_only_shelves_sync = to_save.get("kobo_only_shelves_sync", 0) == "on"
        ub.session.add(content)
        ub.session.commit()
        flash(_("User '%(user)s' created", user=content.name), category="success")
        log.debug("User {} created".format(content.name))
        return redirect(url_for('admin.admin'))
    except IntegrityError:
        ub.session.rollback()
        log.error("Found an existing account for {} or {}".format(content.name, content.email))
        flash(_("Oops! An account already exists for this Email. or name."), category="error")
    except OperationalError as e:
        ub.session.rollback()
        log.error_or_exception("Settings Database error: {}".format(e))
        flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")


def _delete_user(content):
    if ub.session.query(ub.User).filter(ub.User.role.op('&')(constants.ROLE_ADMIN) == constants.ROLE_ADMIN,
                                        ub.User.id != content.id).count():
        if content.name != "Guest":
            # Delete all books in shelfs belonging to user, all shelfs of user, downloadstat of user, read status
            # and user itself
            ub.session.query(ub.ReadBook).filter(content.id == ub.ReadBook.user_id).delete()
            ub.session.query(ub.Downloads).filter(content.id == ub.Downloads.user_id).delete()
            for us in ub.session.query(ub.Shelf).filter(content.id == ub.Shelf.user_id):
                ub.session.query(ub.BookShelf).filter(us.id == ub.BookShelf.shelf).delete()
            ub.session.query(ub.Shelf).filter(content.id == ub.Shelf.user_id).delete()
            ub.session.query(ub.Bookmark).filter(content.id == ub.Bookmark.user_id).delete()
            ub.session.query(ub.User).filter(ub.User.id == content.id).delete()
            ub.session.query(ub.ArchivedBook).filter(ub.ArchivedBook.user_id == content.id).delete()
            ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.user_id == content.id).delete()
            ub.session.query(ub.User_Sessions).filter(ub.User_Sessions.user_id == content.id).delete()
            ub.session.query(ub.KoboSyncedBooks).filter(ub.KoboSyncedBooks.user_id == content.id).delete()
            # delete KoboReadingState and all it's children
            kobo_entries = ub.session.query(ub.KoboReadingState).filter(ub.KoboReadingState.user_id == content.id).all()
            for kobo_entry in kobo_entries:
                ub.session.delete(kobo_entry)
            ub.session_commit()
            log.info("User {} deleted".format(content.name))
            return _("User '%(nick)s' deleted", nick=content.name)
        else:
            # log.warning(_("Can't delete Guest User"))
            raise Exception(_("Can't delete Guest User"))
    else:
        # log.warning("No admin user remaining, can't delete user")
        raise Exception(_("No admin user remaining, can't delete user"))


def _handle_edit_user(to_save, content, languages, translations, kobo_support):
    if to_save.get("delete"):
        try:
            flash(_delete_user(content), category="success")
        except Exception as ex:
            log.error(ex)
            flash(str(ex), category="error")
        return redirect(url_for('admin.admin'))
    # Theme update for admin editing user
    if 'theme' in to_save:
        try:
            theme_val = int(to_save.get('theme'))
            if theme_val in (0,1):
                content.theme = theme_val
        except Exception:
            pass
    # Proceed with remaining updates (previously skipped when 'theme' in to_save)
    if not ub.session.query(ub.User).filter(ub.User.role.op('&')(constants.ROLE_ADMIN) == constants.ROLE_ADMIN,
                                            ub.User.id != content.id).count() and 'admin_role' not in to_save:
        log.warning("No admin user remaining, can't remove admin role from {}".format(content.name))
        flash(_("No admin user remaining, can't remove admin role"), category="error")
        return redirect(url_for('admin.admin'))

    val = [int(k[5:]) for k in to_save if k.startswith('show_')]
    sidebar, __ = get_sidebar_config()
    for element in sidebar:
        value = element['visibility']
        if value in val and not content.check_visibility(value):
            content.sidebar_view |= value
        elif value not in val and content.check_visibility(value):
            content.sidebar_view &= ~value

    if to_save.get("Show_detail_random"):
        content.sidebar_view |= constants.DETAIL_RANDOM
    else:
        content.sidebar_view &= ~constants.DETAIL_RANDOM

    old_state = content.kobo_only_shelves_sync
    content.kobo_only_shelves_sync = int(to_save.get("kobo_only_shelves_sync") == "on") or 0
    # 1 -> 0: nothing has to be done
    # 0 -> 1: all synced books have to be added to archived books, + currently synced shelfs
    # which don't have to be synced have to be removed (added to Shelf archive)
    if old_state == 0 and content.kobo_only_shelves_sync == 1:
        kobo_sync_status.update_on_sync_shelfs(content.id)
    # Auto-send and metadata fetch settings
    content.auto_send_enabled = to_save.get("auto_send_enabled") == "on"
    content.auto_metadata_fetch = to_save.get("auto_metadata_fetch") == "on"
    if to_save.get("default_language"):
        content.default_language = to_save["default_language"]
    if to_save.get("locale"):
        content.locale = to_save["locale"]
    try:
        anonymous = content.is_anonymous
        content.role = constants.selected_roles(to_save)
        if anonymous:
            content.role |= constants.ROLE_ANONYMOUS
        else:
            content.role &= ~constants.ROLE_ANONYMOUS
            if to_save.get("password", ""):
                content.password = generate_password_hash(helper.valid_password(to_save.get("password", "")))

        new_email = valid_email(to_save.get("email", content.email))
        if not new_email:
            raise Exception(_("Email can't be empty and has to be a valid Email"))
        if new_email != content.email:
            content.email = check_email(new_email)
        # Query username, if not existing, change
        if to_save.get("name", content.name) != content.name:
            if to_save.get("name") == "Guest":
                raise Exception(_("Guest Name can't be changed"))
            content.name = check_username(to_save["name"])
        if to_save.get("kindle_mail") != content.kindle_mail:
            content.kindle_mail = valid_email(to_save["kindle_mail"]) if to_save["kindle_mail"] else ""
        if to_save.get("kindle_mail_subject") is not None:
            content.kindle_mail_subject = (to_save.get("kindle_mail_subject", "") or "").strip()

    except Exception as ex:
        log.error(ex)
        flash(str(ex), category="error")
        return render_title_template("user_edit.html",
                                     translations=translations,
                                     languages=languages,
                                     mail_configured=config.get_mail_server_configured(),
                                     kobo_support=kobo_support,
                                     new_user=0,
                                     content=content,
                                     config=config,
                                     registered_oauth=oauth_check,
                                     title=_("Edit User %(nick)s", nick=content.name),
                                     page="edituser")
    try:
        ub.session_commit()
        flash(_("User '%(nick)s' updated", nick=content.name), category="success")
    except IntegrityError as ex:
        ub.session.rollback()
        log.error("An unknown error occurred while changing user: {}".format(str(ex)))
        flash(_("Oops! An unknown error occurred. Please try again later."), category="error")
    except OperationalError as e:
        ub.session.rollback()
        log.error_or_exception("Settings Database error: {}".format(e))
        flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
    return ""


def extract_user_data_from_field(user, field):
    match = re.search(field + r"=(.*?)($|(?<!\\),)", user, re.IGNORECASE | re.UNICODE)
    if match:
        return match.group(1)
    else:
        raise Exception("Could Not Parse LDAP User: {}".format(user))


def extract_dynamic_field_from_filter(user, filtr):
    match = re.search(r"([a-zA-Z0-9-]+)=%s", filtr, re.IGNORECASE | re.UNICODE)
    if match:
        return match.group(1)
    else:
        raise Exception("Could Not Parse LDAP Userfield: {}", user)


def extract_user_identifier(user, filtr):
    dynamic_field = extract_dynamic_field_from_filter(user, filtr)
    return extract_user_data_from_field(user, dynamic_field)


@admi.route("/admin/test_oidc", methods=["POST"])
@user_login_required
@admin_required
def test_oidc():
    url = request.get_json().get('url')
    if not url:
        return json.dumps({'success': False, 'message': 'URL is required.'}), 400

    if not url.startswith('http'):
        url = 'https://' + url

    discovery_url = url.rstrip('/') + '/.well-known/openid-configuration'

    try:
        response = requests.get(discovery_url, timeout=5, verify=constants.OAUTH_SSL_STRICT)
        response.raise_for_status()
        # Try to parse the JSON and extract useful information
        oidc_config = response.json()

        # Extract key endpoints for validation
        endpoints = []
        if 'authorization_endpoint' in oidc_config:
            endpoints.append('authorization')
        if 'token_endpoint' in oidc_config:
            endpoints.append('token')
        if 'userinfo_endpoint' in oidc_config:
            endpoints.append('userinfo')

        endpoint_info = " Found endpoints: " + ', '.join(endpoints) + "." if endpoints else ""

        return json.dumps({
            'success': True,
            'message': _('Connection successful! OIDC discovery endpoint is accessible.%(endpoints)s',
                        endpoints=endpoint_info)
        })
    except requests.exceptions.HTTPError as e:
        if e.response.status_code == 404:
            return json.dumps({'success': False, 'message': _('Connection failed: OIDC discovery endpoint not found (404). Check if the base URL is correct.')}), 200
        else:
            return json.dumps({'success': False, 'message': _('Connection failed: Server returned status code %(code)s', code=e.response.status_code)}), 200
    except requests.exceptions.ConnectionError:
        return json.dumps({'success': False, 'message': _('Connection failed: Could not connect to server. Check the URL and network connectivity.')}), 200
    except requests.exceptions.Timeout:
        return json.dumps({'success': False, 'message': _('Connection failed: Request timed out. The server may be slow or unreachable.')}), 200
    except ValueError:
        return json.dumps({'success': False, 'message': _('Connection failed: Server returned invalid JSON. This may not be an OIDC endpoint.')}), 200
    except Exception as e:
        log.error("OIDC test connection failed: %s", e)
        return json.dumps({'success': False, 'message': _('Connection failed: %(error)s', error=str(e))}), 200


@admi.route("/admin/test_metadata", methods=["POST"])
@user_login_required
@admin_required
def test_metadata():
    metadata_url = request.get_json().get('url')
    if not metadata_url:
        return json.dumps({'success': False, 'message': 'Metadata URL is required.'}), 400

    if not metadata_url.startswith('http'):
        metadata_url = 'https://' + metadata_url

    try:
        response = requests.get(metadata_url, timeout=5, verify=constants.OAUTH_SSL_STRICT)
        response.raise_for_status()
        data = response.json()

        # Validate that it contains required OIDC fields
        required_fields = ['issuer', 'authorization_endpoint', 'token_endpoint']
        missing_fields = [field for field in required_fields if not data.get(field)]

        if missing_fields:
            return json.dumps({
                'success': False,
                'message': _('Metadata is missing required OIDC fields: %(fields)s. This may not be a valid OIDC metadata endpoint.',
                            fields=', '.join(missing_fields))
            }), 200

        # Count available OAuth endpoints for user feedback
        oauth_endpoints = ['authorization_endpoint', 'token_endpoint', 'userinfo_endpoint',
                          'end_session_endpoint', 'introspection_endpoint', 'revocation_endpoint']
        found_endpoints = [ep for ep in oauth_endpoints if ep in data]
        endpoint_count = len(found_endpoints)
        has_userinfo = 'userinfo_endpoint' in data

        message = _('Metadata URL is valid! Found %(count)s OAuth endpoints.', count=endpoint_count)
        if has_userinfo:
            message += _(' User info endpoint is available.')
        else:
            message += _(' Note: User info endpoint not found - this may cause authentication issues.')

        return json.dumps({'success': True, 'message': message})
    except requests.exceptions.HTTPError as e:
        if e.response.status_code == 404:
            return json.dumps({'success': False, 'message': _('Metadata URL not found (404). Please check the URL is correct.')}), 200
        else:
            return json.dumps({'success': False, 'message': _('Connection failed: Server returned status code %(code)s', code=e.response.status_code)}), 200
    except requests.exceptions.ConnectionError:
        return json.dumps({'success': False, 'message': _('Connection failed: Could not connect to metadata URL. Check the URL and network connectivity.')}), 200
    except requests.exceptions.Timeout:
        return json.dumps({'success': False, 'message': _('Connection failed: Request timed out.')}), 200
    except ValueError:
        return json.dumps({'success': False, 'message': _('Connection failed: Invalid JSON in response.')}), 200
    except Exception as e:
        log.error("Metadata test failed: %s", e)
        return json.dumps({'success': False, 'message': _('An unknown error occurred.')}), 200
